Skip to content

cuda: make graph-cache eviction reachable; qwen3_asr: survive concurrent-load memory pressure - #293

Merged
0xShug0 merged 3 commits into
0xShug0:mainfrom
derekja:fix/cuda-graph-eviction
Aug 22, 2026
Merged

cuda: make graph-cache eviction reachable; qwen3_asr: survive concurrent-load memory pressure#293
0xShug0 merged 3 commits into
0xShug0:mainfrom
derekja:fix/cuda-graph-eviction

Conversation

@derekja

@derekja derekja commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #276. That fix made serial qwen3_asr load memory-stable (verified: three identical single-stream sweeps, VRAM byte-identical). Under concurrent /v1/audio/transcriptions load a distinct failure remained: VRAM grows on every run — including identical repeats of the same shapes — until allocation fails, after which every request returns 500 until the process is restarted.

Measured on a 12 GB H100L vGPU slice (no VMM), Qwen3-ASR-0.6B Q8, 8 concurrent streams of mixed 1–8 s utterances posting accumulated audio ~1/s: 6.2 → 8.9 → 10.5 GB (ceiling) across repeated sweeps, then permanent 500s. nemotron_asr under the identical workload is byte-stable, which localized the problem to the qwen3_asr graph lifecycle. With GGML_CUDA_DISABLE_GRAPHS=1 the same workload is also byte-stable, which localized the reservoir.

Four changes, in causal order:

  1. Export ggml_backend_cuda_clear_graph through get_proc_address. Every graph destructor already calls engine::core::release_backend_graph_resources, which resolves this name — but the CUDA registry never exported it, so eviction has been a silent no-op since it was added (docs/build/HIP.md claims it works; now it does). Single-threaded servers got away with it because the freed graph arena's address is typically reused by the next same-size ggml_init, overwriting the stale cuda_graphs entry in place; concurrent requests (one detached thread per request) perturb the address space, so each rebuild minted a fresh key and orphaned a cudaGraph_t + cudaGraphExec_t.

  2. Release the old graph before constructing its replacement (prefill/decode/classification/encoder). Assigning make_unique over a live unique_ptr holds both arenas at the rebuild peak — and interleaved streams force a rebuild on nearly every request because the prefill/encoder caches are exact-shape single slots.

  3. Hold ggml_gallocr_t in a unique_ptr (the existing voxtral_realtime pattern) so the CapacityError/runtime_error throws in the graph constructors stop leaking the partially reserved arena. Previously every failed rebuild after an OOM deepened the OOM.

  4. Trim idle pool memory and retry once when graph allocation fails. The legacy pool (no-VMM path) caches every buffer it ever allocated and only flushes when its own cudaMalloc fails; graph arenas allocate through ggml_backend_cuda_buffer_type_alloc_buffer, which flushes nothing — so once the pool ratchets to the ceiling, every graph build fails forever while gigabytes of idle cached buffers sit reclaimable. Adds ggml_cuda_pool::clear(), an exported ggml_backend_cuda_trim_pools(), a framework resolver, and trim-and-retry on the four qwen3_asr allocation-failure paths. The trim costs one device sync and only runs on a previously-fatal failure.

Validation (same slice/model/harness): the workload that previously produced the terminal all-500 state now self-heals — at the ceiling the trim reclaimed 2.3 GB mid-load, 30/32 utterances in the pressure-point sweep still transcribed, and the server returned to full health (WER identical to baseline, finals p50 9 ms) with no restart. Serial behavior is unchanged. Transcripts across the load runs are unchanged (corpus WER 0.05 at every level, same as pre-patch).

Repro used throughout: N concurrent realtime websocket streams → accumulated-utterance POSTs of mixed lengths; happy to share the harness scripts if useful.

@derekja

derekja commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

RTF data, audiocpp_cli --metrics as in the #276 review. Same slice as the PR description (12 GB H100L vGPU, CUDA backend). Method: one --request-sequence session per family per binary — 6 requests per audio length, first request per length excluded as graph build, p50 (min–max) of the 5 warm runs. Baseline is main @ 54ea3d1, patched is this branch.

qwen3_asr 0.6B Q8 (the family this PR touches):

audio main patched
2 s 0.0326 (0.0326–0.0394) 0.0258 (0.0258–0.0285)
6 s 0.0402 (0.0400–0.0429) 0.0354 (0.0354–0.0368)
15 s 0.0243 (0.0242–0.0252) 0.0242 (0.0241–0.0250)
30 s 0.0259 (0.0259–0.0264) 0.0258 (0.0257–0.0262)

nemotron_asr 0.6B Q8 (regression check for the shared ggml/framework changes):

audio main patched
2 s 0.0098 (0.0096–0.0157) 0.0096 (0.0095–0.0155)
6 s 0.0097 (0.0094–0.0129) 0.0092 (0.0087–0.0121)
15 s 0.0067 (0.0066–0.0080) 0.0065 (0.0060–0.0071)
30 s 0.0058 (0.0057–0.0072) 0.0065 (0.0064–0.0070)

Long-file RTF is unchanged on both families and nemotron sits inside its baseline spread everywhere. Short/medium qwen is measurably faster on the patched build (2 s: 0.0326 → 0.0258; ranges don't overlap) — plausibly the reset-before-rebuild change relieving allocator pressure, though I haven't isolated it; no length is slower. Consistent with the patch adding no code to the warm path (eviction runs at graph destruction, the pool trim only on a previously-fatal allocation failure).

@0xShug0

0xShug0 commented Aug 22, 2026

Copy link
Copy Markdown
Owner

@derekja Could you clean up the churn in the PR caused by the CRLF changes? Changes to GGML are usually high-risk because of their large blast radius and generally require a regression sweep, unless they’re purely additive and opt-in. The current PR shows full-file churn, which makes it difficult to tell what actually changed.

derekja and others added 2 commits August 22, 2026 07:44
… before rebuild, exception-safe gallocr

Follow-up to 0xShug0#276. Under concurrent /v1/audio/transcriptions load the
qwen3_asr family leaks VRAM until allocation fails, after which every
request 500s until process restart. Serial load is stable (0xShug0#276 works);
the concurrent-only signature is the tell:

ggml's CUDA-graph cache (cuda_graphs) is keyed by cgraph->nodes[0], a
host pointer into the graph's ggml_init arena. Every graph destructor
already calls engine::core::release_backend_graph_resources, which looks
up "ggml_backend_cuda_clear_graph" by proc address -- but the CUDA
backend never exported that name, so eviction has been a silent no-op
since it was added. Single-threaded servers get away with it: the arena
is munmap'd and the next same-size ggml_init reuses the address, so the
stale entry is overwritten in place. Concurrent requests (one detached
thread per HTTP request) perturb the address space, each rebuild mints a
fresh key, and the orphaned entries -- each holding a cudaGraph_t +
cudaGraphExec_t -- accumulate until cudaMalloc fails.

Measured on a 12 GB H100L vGPU slice, Qwen3-ASR-0.6B Q8, 8 concurrent
streams of mixed 1-8 s utterances: VRAM 6.2 -> 8.9 -> 10.5 GB (ceiling)
across identical repeated sweeps, then permanent 500s. Identical serial
sweeps: byte-stable. nemotron_asr under the same concurrent load:
byte-stable (its shapes do not churn), which localized the leak.

Three changes:
1. ggml-cuda.cu: export ggml_backend_cuda_clear_graph through
   get_proc_address, making the existing destructor-side eviction calls
   effective (docs/build/HIP.md already claims this works; now it does).
2. qwen3_asr thinker/audio_encoder: reset the old graph before
   constructing its replacement. Assigning make_unique over a live
   unique_ptr holds both arenas at the rebuild peak, which doubles the
   transient footprint precisely when interleaved streams force a
   rebuild on nearly every request.
3. qwen3_asr: hold ggml_gallocr_t in a unique_ptr (the
   voxtral_realtime pattern) so the CapacityError/runtime_error throws
   in the graph constructors stop leaking the partially reserved arena
   -- previously every failed rebuild after an OOM deepened the OOM.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The legacy CUDA pool (no VMM) caches every buffer it ever allocated and
only flushes when ITS OWN cudaMalloc fails; graph arenas allocate through
ggml_backend_cuda_buffer_type_alloc_buffer, which does not flush anything.
Under concurrent load with CUDA graphs enabled the pool ratchets up to the
device ceiling, after which every graph (re)build fails permanently even
though gigabytes of idle cached buffers are reclaimable -- the terminal
all-500s state. (With GGML_CUDA_DISABLE_GRAPHS=1 the same workload is
byte-stable, which is how the pool was isolated as the reservoir.)

Adds ggml_cuda_pool::clear() (no-op by default, clear_pool() on the
legacy pool), an exported ggml_backend_cuda_trim_pools(), a framework
resolver engine::core::trim_backend_pools(), and a trim-and-retry-once on
the allocation-failure paths of all four qwen3_asr graphs. A trim costs a
device sync and only ever fires on a failure that was previously fatal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@derekja
derekja force-pushed the fix/cuda-graph-eviction branch from e9667c0 to cb427c9 Compare August 22, 2026 07:44
@derekja

derekja commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

you bet, thanks. running regression tests overnight and will post when complete.

@derekja

derekja commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the careful look, and apologies for the churn — that was my tooling silently normalizing line endings on the three external/ggml files (they're stored CRLF, so a text-mode edit rewrote every line). I've rebuilt both commits from main's exact bytes with only the real insertions and force-pushed; the PR now shows the actual change: +110/−22 across 7 files.

On blast radius, agreed — here's the breakdown and the regression sweep.

What the ggml side actually changes (+29 lines, no deletions): two proc-address table entries, ggml_backend_cuda_trim_pools() (new, reachable only via the new proc entry), a clear() override on the legacy pool that calls its existing clear_pool(), and a virtual void clear() {} with a default no-op body on the pool base. Nothing existing is modified. The one behavioral change to pre-existing code is the point of the fix: release_backend_graph_resources — already called by every family's graph destructors — stops being a silent no-op on CUDA, so the sweep below deliberately covers multiple families, not just qwen3_asr.

Regression sweep (12 GB H100L vGPU, CUDA backend, this branch):

  • ctest (-DENGINE_BUILD_TESTS=ON): 56/56 passed.
  • run_audiocpp_cli_path_tests.py + compare_audiocpp_cli_path_results.py, baseline main @ 54ea3d1 CLI vs patched CLI, every family I have local models for: qwen3_asr (3 cases), nemotron_asr (2), citrinet_asr (1), hviske_asr (1), voxcpm2 (2 — a TTS family, included since the eviction change fires in all families' destructors): 9/9 compared cases, 0 differences — artifacts and text byte-match between the two builds. Caveats for reproducibility: the resources/*.wav fixtures aren't in the repo, so I substituted local audio (identical for both runs, which is what the A/B needs); the qwen forced-aligner case exercises the aligner model I don't have installed and the voxcpm2 voice-clone case failed identically on both builds against my substitute reference clip — both failures are byte-identical base-vs-patched and counted in the 0-difference comparison.
  • RTF: the --metrics A/B in the comment above (equal-or-faster, no length regressed).
  • The concurrent-load soak from the PR description, re-run on the clean branch: previously-fatal workload self-heals; serial byte-stability unchanged.

One pre-existing note from running the unit suite: codec_dequant_parity (tests/moss_tts_local) failed to compile on the first full-tests build against current main headers — unrelated to this PR (neither file is in this diff); mentioning it in case it's already known.

Happy to run any additional sweep you'd like — the concurrent-load harness that produced the leak repro is scriptable and I can share it.

@0xShug0

0xShug0 commented Aug 22, 2026

Copy link
Copy Markdown
Owner

@derekja What worries me most is the ggml_backend_cuda_clear_graph change

if (strcmp(name, "ggml_backend_cuda_clear_graph") == 0) {
    return (void *)ggml_backend_cuda_clear_graph;
}

I agree this is a correctness fix: before this PR, release_backend_graph_resources(...) was effectively a no-op for CUDA. The concern is that this correctness fix can expose lifecycle/performance issues in models that destroy and rebuild graphs between requests. A common use case (mostly for DiT models) is sending the same request multiple times with only a different seed to find the best results. The graph shape does not change, so before this PR CUDA graph cache state could be reused accidentally -- a happy coincidence. E.g. I tested MiniMax-H3 text-to-audio with default settings in one server session, using repeated requests. The PR introduced about a 9% warmed-request regression in that case.

main warmed avg (across 5 requests): 4460.38 ms
PR warmed avg: 4858.80 ms
delta: +398.42 ms
regression: +8.93%

Depending on the GPU, model, and request shape, the CUDA graph rebuild cost may be small enough to look like noise, but it can also be noticeable.

So what could delay the merge now is that we need to audit which models are affected by this lifecycle change, then refactor and validate the affected models before merging the PR.

I think a safer approach is to make the new CUDA graph cleanup opt-in for now, with only Qwen3-ASR enabling it initially. Any thoughts?

@derekja

derekja commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Sure, that makes sense. I'm happy to take on some of the testing to make it more generalizable if you like, although I'd want some guidance on what could be most convincing in that regard. Alternatively, I could see the opt-in as an optional flag on the existing call, or by putting the new behaviour in a new call, evict_backend_graph_cache(backend, graph) or something like that.

Do you have a preference? I'll push something into this PR to optionalize it and re-run the tests, if you like.

@0xShug0

0xShug0 commented Aug 22, 2026

Copy link
Copy Markdown
Owner

@derekja Thanks! I’d prefer a simple opt-in flag on the existing call, something like:

void release_backend_graph_resources(
    ggml_backend_t backend,
    ggml_cgraph * graph,
    bool evict_cuda_graph_cache = false);

void release_backend_graph_resources(
    BackendType backend_type,
    ggml_backend_t backend,
    ggml_cgraph * graph,
    bool evict_cuda_graph_cache = false);

Then just:

void release_backend_graph_resources(
    ggml_backend_t backend,
    ggml_cgraph * graph,
    bool evict_cuda_graph_cache) {
    if (!evict_cuda_graph_cache) return; // existing callsite/behavior unchanged
     ....
}

Default should stay false, so existing models keep the current CUDA graph-cache behavior. Then Qwen3-ASR can opt into the real cleanup path in this PR

release_backend_graph_resources(backend, graph, true);

and we can audit/refactor other models separately later.

…_asr opts in

Per review: families that destroy and rebuild same-shape graphs between
requests inherit a warm CUDA-graph cache from the historical no-op (the
eviction lookup resolved nothing before the export), and evicting for
everyone costs them a re-capture per rebuild (~9% warmed-request on
MiniMax-H3). release_backend_graph_resources gains
evict_cuda_graph_cache=false so every existing call site keeps the
current behavior; qwen3_asr's four graph destructors pass true, keeping
the concurrent-load leak fix where it was measured. Other families can
opt in after audit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@derekja

derekja commented Aug 22, 2026

Copy link
Copy Markdown
Contributor Author

Done — implemented exactly as you sketched: evict_cuda_graph_cache = false on both overloads with the early return, so every existing call site keeps current behavior, and qwen3_asr's four graph destructors pass true. Pushed as one additional commit. Re-validated on this branch: ctest 56/56; the 5-family path-test comparison against main @54ea3d1 still reports 0 differences; and the qwen concurrent-load repro still self-heals with the opt-in in place. Happy to help with the wider audit whenever you get to it — the concurrent-load harness is yours if useful.

@0xShug0
0xShug0 merged commit 4d383be into 0xShug0:main Aug 22, 2026
6 checks passed
@0xShug0

0xShug0 commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Thank you @derekja! PR merged 🎉

NairoDorian added a commit to NairoDorian/speech.cpp that referenced this pull request Aug 22, 2026
Brings speech.cpp up to date with latest audio.cpp upstream main (4d383be):
- Community models: MOSS-VoiceGenerator (PR 0xShug0#278), MMS-300M-1130 forced aligner (PR 0xShug0#279), F5-TTS (PR 0xShug0#275).
- SenseASR encoder refactored to framework SAN-M modules (PR 0xShug0#285).
- Server: max_loaded_models limit with LRU eviction (PR 0xShug0#298) and opt-in session options listing.
- WebUI: reverse proxy hash routing (PR 0xShug0#297), Music3 precision packages, HeartMuLa options.
- CUDA & Memory: CUDA graph-cache eviction and idle pool trimming (PR 0xShug0#293), Supertonic vector arena reduction.
- GGML: tracked CUDA clear_graph and trim_pools as patch 0007.
- Build & CI: native model manager build flags, C++17 cleanups, CMake model-link guards preserved.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants